var io = require('socket.io')
var di = require('di')

var cfg = require('./config')
var logger = require('./logger')
var constant = require('./constants')
var watcher = require('./watcher')
var plugin = require('./plugin')

var ws = require('./web-server')
var preprocessor = require('./preprocessor')
var Launcher = require('./launcher').Launcher
var FileList = require('./file_list').List
var reporter = require('./reporter')
var helper = require('./helper')
var events = require('./events')
var EventEmitter = events.EventEmitter
var Executor = require('./executor')
var Browser = require('./browser')
var BrowserCollection = require('./browser_collection')
var EmitterWrapper = require('./emitter_wrapper')
var processWrapper = new EmitterWrapper(process)

var log = logger.create()

var start = function (injector, config, launcher, globalEmitter, preprocess, fileList, webServer,
  capturedBrowsers, socketServer, executor, done) {
  config.frameworks.forEach(function (framework) {
    injector.get('framework:' + framework)
  })

  // A map of launched browsers.
  var singleRunDoneBrowsers = Object.create(null)

  // Passing fake event emitter, so that it does not emit on the global,
  // we don't care about these changes.
  var singleRunBrowsers = new BrowserCollection(new EventEmitter())

  // Some browsers did not get captured.
  var singleRunBrowserNotCaptured = false

  webServer.on('error', function (e) {
    if (e.code === 'EADDRINUSE') {
      log.warn('Port %d in use', config.port)
      config.port++
      webServer.listen(config.port)
    } else {
      throw e
    }
  })

  var afterPreprocess = function () {
    if (config.autoWatch) {
      injector.invoke(watcher.watch)
    }

    webServer.listen(config.port, function () {
      log.info('Karma v%s server started at http://%s:%s%s', constant.VERSION, config.hostname,
        config.port, config.urlRoot)

      if (config.browsers && config.browsers.length) {
        injector.invoke(launcher.launch, launcher).forEach(function (browserLauncher) {
          singleRunDoneBrowsers[browserLauncher.id] = false
        })
      }
    })
  }

  fileList.refresh().then(afterPreprocess, afterPreprocess)

  globalEmitter.on('browsers_change', function () {
    // TODO(vojta): send only to interested browsers
    socketServer.sockets.emit('info', capturedBrowsers.serialize())
  })

  globalEmitter.on('browser_register', function (browser) {
    launcher.markCaptured(browser.id)

    // TODO(vojta): This is lame, browser can get captured and then crash (before other browsers get
    // captured).
    if (config.autoWatch && launcher.areAllCaptured()) {
      executor.schedule()
    }
  })

  var EVENTS_TO_REPLY = ['start', 'info', 'error', 'result', 'complete']
  socketServer.sockets.on('connection', function (socket) {
    log.debug('A browser has connected on socket ' + socket.id)

    var replySocketEvents = events.bufferEvents(socket, EVENTS_TO_REPLY)

    socket.on('complete', function (data, ack) {
      ack()
    })

    socket.on('register', function (info) {
      var newBrowser
      var isRestart

      if (info.id) {
        newBrowser = capturedBrowsers.getById(info.id) || singleRunBrowsers.getById(info.id)
      }

      if (newBrowser) {
        isRestart = newBrowser.state === Browser.STATE_DISCONNECTED
        newBrowser.reconnect(socket)

        // We are restarting a previously disconnected browser.
        if (isRestart && config.singleRun) {
          newBrowser.execute(config.client)
        }
      } else {
        newBrowser = injector.createChild([{
          id: ['value', info.id || null],
          fullName: ['value', info.name],
          socket: ['value', socket]
        }]).instantiate(Browser)

        newBrowser.init()

        // execute in this browser immediately
        if (config.singleRun) {
          newBrowser.execute(config.client)
          singleRunBrowsers.add(newBrowser)
        }
      }

      replySocketEvents()
    })
  })

  var emitRunCompleteIfAllBrowsersDone = function () {
    // all browsers done
    var isDone = Object.keys(singleRunDoneBrowsers).reduce(function (isDone, id) {
      return isDone && singleRunDoneBrowsers[id]
    }, true)

    if (isDone) {
      var results = singleRunBrowsers.getResults()
      if (singleRunBrowserNotCaptured) {
        results.exitCode = 1
      }

      globalEmitter.emit('run_complete', singleRunBrowsers, results)
    }
  }

  if (config.singleRun) {
    globalEmitter.on('browser_complete', function (completedBrowser) {
      if (completedBrowser.lastResult.disconnected &&
        completedBrowser.disconnectsCount <= config.browserDisconnectTolerance) {
        log.info('Restarting %s (%d of %d attempts)', completedBrowser.name,
          completedBrowser.disconnectsCount, config.browserDisconnectTolerance)
        if (!launcher.restart(completedBrowser.id)) {
          singleRunDoneBrowsers[completedBrowser.id] = true
          emitRunCompleteIfAllBrowsersDone()
        }
      } else {
        singleRunDoneBrowsers[completedBrowser.id] = true

        if (launcher.kill(completedBrowser.id)) {
          // workaround to supress "disconnect" warning
          completedBrowser.state = Browser.STATE_DISCONNECTED
        }

        emitRunCompleteIfAllBrowsersDone()
      }
    })

    globalEmitter.on('browser_process_failure', function (browserLauncher) {
      singleRunDoneBrowsers[browserLauncher.id] = true
      singleRunBrowserNotCaptured = true

      emitRunCompleteIfAllBrowsersDone()
    })

    globalEmitter.on('run_complete', function (browsers, results) {
      log.debug('Run complete, exiting.')
      disconnectBrowsers(results.exitCode)
    })

    globalEmitter.emit('run_start', singleRunBrowsers)
  }

  if (config.autoWatch) {
    globalEmitter.on('file_list_modified', function () {
      log.debug('List of files has changed, trying to execute')
      executor.schedule()
    })
  }

  var webServerCloseTimeout = 3000
  var disconnectBrowsers = function (code) {
    // Slightly hacky way of removing disconnect listeners
    // to suppress "browser disconnect" warnings
    // TODO(vojta): change the client to not send the event (if disconnected by purpose)
    var sockets = socketServer.sockets.sockets
    Object.getOwnPropertyNames(sockets).forEach(function (key) {
      var socket = sockets[key]
      socket.removeAllListeners('disconnect')
      if (!socket.disconnected) {
        socket.disconnect()
      }
    })

    var removeAllListenersDone = false
    var removeAllListeners = function () {
      // make sure we don't execute cleanup twice
      if (removeAllListenersDone) {
        return
      }
      removeAllListenersDone = true
      webServer.removeAllListeners()
      processWrapper.removeAllListeners()
      done(code || 0)
    }

    globalEmitter.emitAsync('exit').then(function () {
      // don't wait forever on webServer.close() because
      // pending client connections prevent it from closing.
      var closeTimeout = setTimeout(removeAllListeners, webServerCloseTimeout)

      // shutdown the server...
      webServer.close(function () {
        clearTimeout(closeTimeout)
        removeAllListeners()
      })

      // shutdown socket.io flash transport, if defined
      if (socketServer.flashPolicyServer) {
        socketServer.flashPolicyServer.close()
      }
    })
  }

  try {
    processWrapper.on('SIGINT', disconnectBrowsers)
    processWrapper.on('SIGTERM', disconnectBrowsers)
  } catch (e) {
    // Windows doesn't support signals yet, so they simply don't get this handling.
    // https://github.com/joyent/node/issues/1553
  }

  // Handle all unhandled exceptions, so we don't just exit but
  // disconnect the browsers before exiting.
  processWrapper.on('uncaughtException', function (error) {
    log.error(error)
    disconnectBrowsers(1)
  })
}
start.$inject = ['injector', 'config', 'launcher', 'emitter', 'preprocess', 'fileList',
  'webServer', 'capturedBrowsers', 'socketServer', 'executor', 'done']

var createSocketIoServer = function (webServer, executor, config) {
  var server = io.listen(webServer, {
    // avoid destroying http upgrades from socket.io to get proxied websockets working
    'destroy upgrade': false,
    // socket.io has a timeout (15s by default) before destroying a store (a data structure where
    // data associated with a socket are stored). Unfortunately this timeout is not cleared
    // properly on socket.io shutdown and this timeout prevents karma from exiting cleanly.
    // We change this timeout to 0 to make Karma exit just after all tests were executed.
    'client store expiration': 0,
    logger: logger.create('socket.io', constant.LOG_ERROR),
    resource: config.urlRoot + 'socket.io',
    transports: config.transports
  })

  // hack to overcome circular dependency
  executor.socketIoSockets = server.sockets

  return server
}

exports.start = function (cliOptions, done) {
  // apply the default logger config (and config from CLI) as soon as we can
  logger.setup(cliOptions.logLevel || constant.LOG_INFO,
    helper.isDefined(cliOptions.colors) ? cliOptions.colors : true, [constant.CONSOLE_APPENDER])

  var config = cfg.parseConfig(cliOptions.configFile, cliOptions)
  var modules = [{
    helper: ['value', helper],
    logger: ['value', logger],
    done: ['value', done || process.exit],
    emitter: ['type', EventEmitter],
    launcher: ['type', Launcher],
    config: ['value', config],
    preprocess: ['factory', preprocessor.createPreprocessor],
    fileList: ['type', FileList],
    webServer: ['factory', ws.create],
    socketServer: ['factory', createSocketIoServer],
    executor: ['type', Executor],
    // TODO(vojta): remove
    customFileHandlers: ['value', []],
    // TODO(vojta): remove, once karma-dart does not rely on it
    customScriptTypes: ['value', []],
    reporter: ['factory', reporter.createReporters],
    capturedBrowsers: ['type', BrowserCollection],
    args: ['value', {}],
    timer: ['value', {setTimeout: setTimeout, clearTimeout: clearTimeout}]
  }]

  // load the plugins
  modules = modules.concat(plugin.resolve(config.plugins))

  var injector = new di.Injector(modules)

  injector.invoke(start)
}
